查看原文
其他

【第2130期】前端元编程——使用注解加速你的前端开发

tylerccwang 前端早读课 2021-01-29

前言

今日前端早读课文章由@小刀授权分享。

正文从这开始~~

无论你用 React,Vue,还是 Angular,你还是要一遍一遍写相似的 CRUD 页面,一遍一遍,一遍一遍,一遍又一遍……

“天下苦秦久矣”~~

前端开发的“痛点”在哪里?

现在的前端开发,我们有了世界一流的 UI 库 React,Vue,Angular,有了样式丰富的 UI 组件库 Tea (腾讯云 UI 组件库,类似 Antd Design), 有了方便强大的脚手架工具(例如,create react app)。但是我们在真正业务代码之前,通常还免不了写大量的样板代码。

现在的 CRUD 页面代码通常:

  • 太轻的“Model”或着“Service”,大多时候只是一些 API 调用的封装。

  • 胖”View“,View 页面中有展示 UI 逻辑,生命周期逻辑,CRUD 的串联逻辑,然后还要塞满业务逻辑代码。

  • 不同的项目业务逻辑不同,但是列表页,表单,搜索这三板斧的样板代码,却要一遍一遍占据着前端工程师的宝贵时间。

特别是 CRUD 类应用的样板代码受限于团队风格,后端 API 风格,业务形态等,通常内在逻辑相似书写上却略有区别,无法通过一个通用的库或者框架来解决(上图中背景越深,越不容易有一个通用的方案)。

说好的“数据驱动的前端开发”呢?

对于这个“痛点”——怎么尽可能的少写模版代码,就是本文尝试解决的问题。

我们尝试使用 JavaScript 新特性Decorator和Reflect元编程来解决这个问题。

前端元编程

从 ECMAScript 2015 开始,JavaScript 获得了 Proxy 和 Reflect 对象的支持,允许你拦截并定义基本语言操作的自定义行为(例如,属性查找,赋值,枚举,函数调用等)。借助这两个对象,你可以在 JavaScript 元级别进行编程。@MDN

在正式开始之前,我们先复习下Decorator和Reflect。

Decorator

这里我们简单介绍 Typescript 的Decorator,ECMAScript 中Decorator尚未定稿,但是不影响我们日常的业务开发(Angular 同学就在使用 Typescript 的Decorator)。

简单来说,Decorator是可以标注修改类及其成员的新语言特性,使用@expression的形式,可以附加到,类、方法、访问符、属性、参数上。

TypeScript 中需要在tsconfig.json中增加experimentalDecorators来支持:

  1. {

  2. "compilerOptions": {

  3. "target": "ES5",

  4. "experimentalDecorators": true

  5. }

  6. }

比如可以使用类修饰器来为类扩展方法。

  1. // offer type

  2. abstract class Base {

  3. log() {}

  4. }


  5. function EnhanceClass() {

  6. return function (Target) {

  7. return class extends Target {

  8. log() {

  9. console.log("---log---");

  10. }

  11. };

  12. };

  13. }

  14. @EnhanceClass()

  15. class Person extends Base {}


  16. const person = new Person();

  17. person.log();


  18. // ---log---

更多查看 typescript 官方的文档。Handbook - Decorators

Reflect

Reflect 是 ES6 中就有的特性,大家可能对它稍微陌生,Vue3 中依赖 Reflect 和 Proxy 来重写它的响应式逻辑。

简单来说,Reflect是一个人内置的对象,提供了拦截 JavaScript 操作的方法。

  1. const _list = [1, 2, 3];

  2. const pList = new Proxy(_list, {

  3. get(target, key, receiver) {

  4. console.log("get value reflect:", key);

  5. return Reflect.get(target, key, receiver);

  6. },

  7. set(target, key, value, receiver) {

  8. console.log("set value reflect", key, value);

  9. return Reflect.set(target, key, value, receiver);

  10. },

  11. });

  12. pList.push(4);

  13. // get value reflect:push

  14. // get value reflect:length

  15. // set value reflect 3 4

  16. // set value reflect length 4

Reflect Metadata

Reflect Metadata 是 ES7 的一个提案,Typescript 1.5+就有了支持。要使用需要:

  1. npm i reflect-metadata --save

tsconfig.json 里配置 emitDecoratorMetadata 选项 简单来说,Reflect Metadata 能够为对象添加和读取元数据。

如下可以使用内置的design:key拿到属性类型:

  1. function Type(): PropertyDecorator {

  2. return function (target, key) {

  3. const type = Reflect.getMetadata("design:type", target, key);

  4. console.log(`${key} type: ${type.name}`);

  5. };

  6. }


  7. class Person extends Base {

  8. @Type()

  9. name: string = "";

  10. }

  11. // name type: String

使用 Decorator,Reflect 减少样板代码

回到正题——使用 Decorator 和 Reflect 来减少 CRUD 应用中的样板代码。

什么是 CRUD 页面?

CRUD 页面无需多言,列表页展示,表单页修改 ……包括 API 调用, 都是围绕某个数据结构(图中Person)展开,增、删、改、查。

基本思路

基本思路很简单,就像上图,Model 是中心,我们就是借助Decorator和Reflect将 CRUD 页面所需的样板类方法属性元编程在 Model 上。进一步延伸数据驱动 UI的思路。

  • 借助 Reflect Matadata 绑定 CRUD 页面信息到 Model 的属性上

  • 借助 Decorator 增强 Model,生成 CRUD 所需的夜班代码

Show Me The Code

下文,我们用TypeScript和React为例,组件库使用腾讯Tea component 解说这个方案。

首先我们有一个函数来生成不同业务的属性装饰函数。

  1. function CreateProperDecoratorF<T>() {

  2. const metaKey = Symbol();

  3. function properDecoratorF(config: T): PropertyDecorator {

  4. return function (target, key) {

  5. Reflect.defineMetadata(metaKey, config, target, key);

  6. };

  7. }

  8. return { metaKey, properDecoratorF };

  9. }

一个类装饰器,处理通过数据装饰器收集上来的元数据。

  1. export function EnhancedClass(config: ClassConfig) {

  2. return function (Target) {

  3. return class EnhancedClass extends Target {};

  4. };

  5. }

API Model 映射

TypeScript 项目中第一步自然是将后端数据安全地转换为type,interface或者Class,这里 Class 能在编译后在 JavaScript 存在,我们选用Class。

  1. export interface TypePropertyConfig {

  2. handle?: string | ServerHandle;

  3. }


  4. const typeConfig = CreateProperDecoratorF<TypePropertyConfig>();

  5. export const Type = typeConfig.properDecoratorF;


  6. @EnhancedClass({})

  7. export class Person extends Base {

  8. static sexOptions = ["male", "female", "unknow"];


  9. @Type({

  10. handle: "ID",

  11. })

  12. id: number = 0;


  13. @Type({})

  14. name: string = "";


  15. @Type({

  16. handle(data, key) {

  17. return parseInt(data[key] || "0");

  18. },

  19. })

  20. age: number = 0;


  21. @Type({

  22. handle(data, key) {

  23. return Person.sexOptions.includes(data[key]) ? data[key] : "unknow";

  24. },

  25. })

  26. sex: "male" | "female" | "unknow" = "unknow";

  27. }

重点在handle?: string | ServerHandle函数,在这个函数处理 API 数据和前端数据的转换,然后在constructor中集中处理。

  1. export function EnhancedClass(config: ClassConfig) {

  2. return function (Target) {

  3. return class EnhancedClass extends Target {

  4. constructor(data) {

  5. super(data);

  6. Object.keys(this).forEach((key) => {

  7. const config: TypePropertyConfig = Reflect.getMetadata(

  8. typeConfig.metaKey,

  9. this,

  10. key

  11. );

  12. this[key] = config.handle

  13. ? typeof config.handle === "string"

  14. ? data[config.handle]

  15. : config.handle(data, key)

  16. : data[key];

  17. });

  18. }

  19. };

  20. };

  21. }

列表页 TablePage

列表页中一般使用 Table 组件,无论是 Tea Component 还是 Antd Design Component 中,样板代码自然就是写那一大堆 Colum 配置了,配置哪些 key 要展示,表头是什么,数据转化为显示数据……

首先我们收集 Tea Table 所需的TableColumn类型的 column 元数据。

  1. import { TableColumn } from "tea-component/lib/table";

  2. export type EnhancedTableColumn<T> = TableColumn<T>;

  3. export type ColumnPropertyConfig = Partial<EnhancedTableColumn<any>>;


  4. const columnConfig = CreateProperDecoratorF<ColumnPropertyConfig>();

  5. export const Column = columnConfig.properDecoratorF;


  6. @EnhancedClass({})

  7. export class Person extends Base {

  8. static sexOptions = ["male", "female", "unknow"];


  9. id: number = 0;


  10. @Column({

  11. header: "person name",

  12. })

  13. name: string = "";


  14. @Column({

  15. header: "person age",

  16. })

  17. age: number = 0;


  18. @Column({})

  19. sex: "male" | "female" | "unknow" = "unknow";

  20. }

然后在 EnhancedClass 中收集,生成 column 列表。

  1. function getConfigMap<T>(

  2. F: any,

  3. cachekey: symbol,

  4. metaKey: symbol

  5. ): Map<string, T> {

  6. if (F[cachekey]) {

  7. return F[cachekey]!;

  8. }

  9. const item = new F({});

  10. F[cachekey] = Object.keys(item).reduce((pre, cur) => {

  11. const config: T = Reflect.getMetadata(metaKey, item, cur);

  12. if (config) {

  13. pre.set(cur, config);

  14. }

  15. return pre;

  16. }, new Map<string, T>());

  17. return F[cachekey];

  18. }


  19. export function EnhancedClass(config: ClassConfig) {

  20. const cacheColumnConfigKey = Symbol("cacheColumnConfigKey");

  21. return function (Target) {

  22. return class EnhancedClass extends Target {

  23. [cacheColumnConfigKey]: Map<string, ColumnPropertyConfig> | null;

  24. /**

  25. * table column config

  26. */

  27. static get columnConfig(): Map<string, ColumnPropertyConfig> {

  28. return getConfigMap<ColumnPropertyConfig>(

  29. EnhancedClass,

  30. cacheColumnConfigKey,

  31. columnConfig.metaKey

  32. );

  33. }


  34. /**

  35. * get table colums

  36. */

  37. static getColumns<T>(): EnhancedTableColumn<T>[] {

  38. const list: EnhancedTableColumn<T>[] = [];

  39. EnhancedClass.columnConfig.forEach((config, key) => {

  40. list.push({

  41. key,

  42. header: key,

  43. ...config,

  44. });

  45. });

  46. return list;

  47. }

  48. };

  49. };

  50. }

Table 数据一般是分页,而且调用方式通常很通用,也可以在 EnhancedClass 中实现。

  1. export interface PageParams {

  2. pageIndex: number;

  3. pageSize: number;

  4. }


  5. export interface Paginabale<T> {

  6. total: number;

  7. list: T[];

  8. }

  9. export function EnhancedClass(config: ClassConfig) {

  10. return function (Target) {

  11. return class EnhancedClass extends Target {

  12. static async getList<T>(params: PageParams): Promise<Paginabale<T>> {

  13. const result = await getPersonListFromServer(params);

  14. return {

  15. total: result.count,

  16. list: result.data.map((item) => new EnhancedClass(item)),

  17. };

  18. }

  19. };

  20. };

  21. }

自然我们封装一个更简易的 Table 组件。

  1. import { Table as TeaTable } from "tea-component/lib/table";

  2. import React, { FC, useEffect, useState } from "react";

  3. import { EnhancedTableColumn, Paginabale, PageParams } from "./utils";

  4. import { Person } from "./person.service";


  5. function Table<T>(props: {

  6. columns: EnhancedTableColumn<T>[];

  7. getListFun: (param: PageParams) => Promise<Paginabale<T>>;

  8. }) {

  9. const [isLoading, setIsLoading] = useState(false);

  10. const [recordData, setRecordData] = useState<Paginabale<T>>();

  11. const [pageIndex, setPageIndex] = useState(1);

  12. const [pageSize, setPageSize] = useState(20);

  13. useEffect(() => {

  14. (async () => {

  15. setIsLoading(true);

  16. const result = await props.getListFun({

  17. pageIndex,

  18. pageSize,

  19. });

  20. setIsLoading(false);

  21. setRecordData(result);

  22. })();

  23. }, [pageIndex, pageSize]);

  24. return (

  25. <TeaTable

  26. columns={props.columns}

  27. records={recordData ? recordData.list : []}

  28. addons={[

  29. TeaTable.addons.pageable({

  30. recordCount: recordData ? recordData.total : 0,

  31. pageIndex,

  32. pageSize,

  33. onPagingChange: ({ pageIndex, pageSize }) => {

  34. setPageIndex(pageIndex || 0);

  35. setPageSize(pageSize || 20);

  36. },

  37. }),

  38. ]}

  39. />

  40. );

  41. }


  42. export default Table;

getConfigMap<T>(F:any,cachekey:symbol,metaKey:symbol):Map<string,T> 收集元数据到 Map

staticgetColumns<T>():EnhancedTableColumn<T>[] 得到 table 可用 column 信息。

  1. const App = () => {

  2. const columns = Person.getColumns<Person>();

  3. const getListFun = useCallback((param: PageParams) => {

  4. return Person.getList<Person>(param);

  5. }, []);

  6. return <Table<Person> columns={columns} getListFun={getListFun} />;

  7. };

效果很明显,不是吗? 7 行写一个 table page。

Form 表单页

表单,自然就是字段的 name,label,require,validate,以及提交数据的转换。

Form 表单我们使用Formik + Tea Form Component + yup(数据校验)。Formik 使用 React Context 来提供表单控件所需的各种方法数据,然后借助提供的 Field 等组件,你可以很方便的封装你的业务表单组件。

  1. import React, { FC } from "react";

  2. import { Field, Form, Formik, FormikProps } from "formik";

  3. import { Form as TeaForm, FormItemProps } from "tea-component/lib/form";

  4. import { Input, InputProps } from "tea-component/lib/input";

  5. import { Select } from "tea-component/lib/select";


  6. type CustomInputProps = Partial<InputProps> &

  7. Pick<FormItemProps, "label" | "name">;


  8. type CustomSelectProps = Partial<InputProps> &

  9. Pick<FormItemProps, "label" | "name"> & {

  10. options: string[];

  11. };


  12. export const CustomInput: FC<CustomInputProps> = (props) => {

  13. return (

  14. <Field name={props.name}>

  15. {({

  16. field, // { name, value, onChange, onBlur }

  17. form: { touched, errors }, // also values, setXXXX, handleXXXX, dirty, isValid, status, etc.

  18. meta,

  19. }) => {

  20. return (

  21. <TeaForm.Item

  22. label={props.label}

  23. required={props.required}

  24. status={meta.touched && meta.error ? "error" : undefined}

  25. message={meta.error}

  26. >

  27. <Input

  28. type="text"

  29. {...field}

  30. onChange={(value, ctx) => {

  31. field.onChange(ctx.event);

  32. }}

  33. />

  34. </TeaForm.Item>

  35. );

  36. }}

  37. </Field>

  38. );

  39. };


  40. export const CustomSelect: FC<CustomSelectProps> = (props) => {

  41. return (

  42. <Field name={props.name}>

  43. {({

  44. field, // { name, value, onChange, onBlur }

  45. form: { touched, errors }, // also values, setXXXX, handleXXXX, dirty, isValid, status, etc.

  46. meta,

  47. }) => {

  48. return (

  49. <TeaForm.Item

  50. label={props.label}

  51. required={props.required}

  52. status={meta.touched && meta.error ? "error" : undefined}

  53. message={meta.error}

  54. >

  55. <Select

  56. {...field}

  57. options={props.options.map((value) => ({ value }))}

  58. onChange={(value, ctx) => {

  59. field.onChange(ctx.event);

  60. }}

  61. />

  62. </TeaForm.Item>

  63. );

  64. }}

  65. </Field>

  66. );

  67. };

照猫画虎,我们还是先收集 form 所需的元数据

  1. import * as Yup from "yup";


  2. export interface FormPropertyConfig {

  3. validationSchema?: any;

  4. label?: string;

  5. handleSubmitData?: (data: any, key: string) => { [key: string]: any };

  6. required?: boolean;

  7. initValue?: any;

  8. options?: string[];

  9. }


  10. const formConfig = CreateProperDecoratorF<FormPropertyConfig>();

  11. export const Form = formConfig.properDecoratorF;


  12. @EnhancedClass({})

  13. export class Person extends Base {

  14. static sexOptions = ["male", "female", "unknow"];


  15. @Type({

  16. handle: "ID",

  17. })

  18. id: number = 0;


  19. @Form({

  20. label: "Name",

  21. validationSchema: Yup.string().required("Name is required"),

  22. handleSubmitData(data, key) {

  23. return {

  24. [key]: (data[key] as string).toUpperCase(),

  25. };

  26. },

  27. required: true,

  28. initValue: "test name",

  29. })

  30. name: string = "";


  31. @Form({

  32. label: "Age",

  33. validationSchema: Yup.string().required("Age is required"),

  34. handleSubmitData(data, key) {

  35. return {

  36. [key]: parseInt(data[key] || "0"),

  37. };

  38. },

  39. required: true,

  40. })

  41. age: number = 0;


  42. @Form({

  43. label: "Sex",

  44. options: Person.sexOptions,

  45. })

  46. sex: "male" | "female" | "unknow" = "unknow";

  47. }

有了元数据,我们可以在 EnhancedClass 中生成 form 所需:

  • initialValues

  • 数据校验的 validationSchema

  • 各个表单组件所需的,name,label,required 等

  • 提交表单的数据转换 handle 函数

代码:

  1. export type FormItemConfigType<T extends any> = {

  2. [key in keyof T]: {

  3. validationSchema?: any;

  4. handleSubmitData?: FormPropertyConfig["handleSubmitData"];

  5. form: {

  6. label: string;

  7. name: string;

  8. required: boolean;

  9. message?: string;

  10. options: string[];

  11. };

  12. };

  13. };


  14. export function EnhancedClass(config: ClassConfig) {

  15. return function (Target) {

  16. return class EnhancedClass extends Target {

  17. [cacheTypeConfigkey]: Map<string, FormPropertyConfig> | null;

  18. /**

  19. * table column config

  20. */

  21. static get formConfig(): Map<string, FormPropertyConfig> {

  22. return getConfigMap<FormPropertyConfig>(

  23. EnhancedClass,

  24. cacheTypeConfigkey,

  25. formConfig.metaKey

  26. );

  27. }


  28. /**

  29. * get form init value

  30. */

  31. static getFormInitValues<T extends EnhancedClass>(item?: T): Partial<T> {

  32. const data: any = {};

  33. const _item = new EnhancedClass({});

  34. EnhancedClass.formConfig.forEach((config, key) => {

  35. if (item && key in item) {

  36. data[key] = item[key];

  37. } else if ("initValue" in config) {

  38. data[key] = config.initValue;

  39. } else {

  40. data[key] = _item[key] || "";

  41. }

  42. });

  43. return data as Partial<T>;

  44. }


  45. static getFormItemConfig<T extends EnhancedClass>(overwriteConfig?: {

  46. [key: string]: any;

  47. }): FormItemConfigType<T> {

  48. const formConfig: any = {};

  49. EnhancedClass.formConfig.forEach((config, key) => {

  50. formConfig[key] = {

  51. form: {

  52. label: String(config.label || key),

  53. name: String(key),

  54. required: !!config.validationSchema,

  55. options: config.options || [],

  56. ...overwriteConfig,

  57. },

  58. };

  59. if (config.validationSchema) {

  60. formConfig[key].validationSchema = config.validationSchema;

  61. }

  62. if (config.handleSubmitData) {

  63. formConfig[key].handleSubmitData = config.handleSubmitData;

  64. }

  65. });

  66. return formConfig as FormItemConfigType<T>;

  67. }


  68. static handleToFormData<T extends EnhancedClass>(item: T) {

  69. let data = {};

  70. EnhancedClass.formConfig.forEach((config, key) => {

  71. if (item.hasOwnProperty(key)) {

  72. data = {

  73. ...data,

  74. ...(EnhancedClass.formConfig.get(key).handleSubmitData

  75. ? EnhancedClass.formConfig.get(key).handleSubmitData(item, key)

  76. : {

  77. [key]: item[key] || "",

  78. }),

  79. };

  80. }

  81. });

  82. return data;

  83. }

  84. };

  85. };

  86. }

在 FormPage 中使用

  1. export const PersonForm: FC<{

  2. onClose: () => void

  3. }> = (props) => {

  4. const initialValues = Person.getFormInitValues<Person>();

  5. const formConfig = Person.getFormItemConfig<Person>();

  6. const schema = Object.entries(formConfig).reduce((pre, [key, value]) => {

  7. if (value.validationSchema) {

  8. pre[key] = value.validationSchema;

  9. }

  10. return pre;

  11. }, {});

  12. const validationSchema = Yup.object().shape(schema);


  13. function onSubmit(values) {

  14. const data = Person.handleToFormData(values);

  15. setTimeout(() => {

  16. console.log("---send to server", data);

  17. props.onClose();

  18. }, 10000);

  19. }

  20. return (

  21. <Formik

  22. initialValues={initialValues}

  23. onSubmit={onSubmit}

  24. validationSchema={validationSchema}

  25. >

  26. {(formProps: FormikProps<any>) => {

  27. return (

  28. <TeaForm>

  29. <CustomInput {...formConfig.name.form} />

  30. <CustomInput {...formConfig.age.form} />

  31. <CustomSelect {...formConfig.sex.form} />

  32. <Button

  33. type="primary"

  34. htmlType="submit"

  35. onClick={() => {

  36. formProps.submitForm();

  37. }}

  38. >

  39. Submit

  40. </Button>

  41. </TeaForm>

  42. );

  43. }}

  44. </Formik>

  45. );

  46. };

40 行,我们有了个一个功能完备表单页

元编程减少样板代码Demo:https://github.com/yijian166/ts-model-decorator

效果

上文包含了不少的代码,但是大部头在如何将元数据转换成为页面组件可用的数据,也就是元编程的部分。

而业务页面,7 行的 Table 页面,40 行的 Form 页面,已经非常精简功能完备了。根据笔者实际项目中估计,可以节省至少 40%的代码量。

元编程 vs. 配置系统

写到尾声,你大概会想到某些配置系统,前端 CRUD 这个从古就有的需求,自然早就有方案,用的最多的就是配置系统,在这里不会过多讨论。

简单来说,就是一个单独的系统,配置类似上文的元信息,然后使用固定模版生成代码。

思路实际上和本文的元编程类似,只是元编程成本低,你不需要单独做一个系统,更加轻量灵活,元编程代码在运行时,想象空间更大……

总结

上面只是 table,form 页面的代码展示,由此我们可以引申到很多类似的地方,甚至 API 的调用代码都可以在元编程中处理。

元编程——将元数据转换成为页面组件可用的数据,这部分恰恰可以在团队内非常好共享也需要共同维护的部分,带来的好处也很明显:

  • 最大的好处自然就是生产效率的提高了,而且是低成本的实现效率的提升(相比配置系统)。一些简单单纯的 CURD 页面甚至都不用写代码了。

  • 更易维护的代码:

  • “瘦 View“,专注业务,

  • 更纯粹的 Model,你可以和 redux,mobx 配合,甚至,你可以从 React,换成 Angular)

  • 最后更重要的是,元编程是一个低成本,灵活,渐进的方案。它是一个运行时的方案,你不需要一步到罗马,徐徐图之 …… - ……

最后,本文更多是一次实践,一种思路,一种元编程在前端开发中的应用场景,最重要的还是抛砖引玉,希望前端小伙伴们能形成自己团队的的元编程实践,来解放生产力,更快搬砖~~

关于本文 作者:@小刀 原文:https://zhuanlan.zhihu.com/p/274328551

为你推荐


【第2084期】从生产到消费,设计基于物料的前端开发链路


【第1959期】面向 Model 编程的前端架构设计


欢迎自荐投稿,前端早读课等你来

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存